/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to you under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.calcite.runtime;

import org.checkerframework.checker.nullness.qual.Nullable;

import java.util.Arrays;
import java.util.Locale;

/**
 * Utilities for converting SQL {@code LIKE} and {@code SIMILAR} operators
 * to regular expressions.
 */
public class Like {
  private static final String JAVA_REGEX_SPECIALS = "[]()|^-+*?{}$\\.";
  private static final String SQL_SIMILAR_SPECIALS = "[]()|^-+*_%?{}";
  private static final String [] REG_CHAR_CLASSES = {
      "[:ALPHA:]", "\\p{Alpha}",
      "[:alpha:]", "\\p{Alpha}",
      "[:UPPER:]", "\\p{Upper}",
      "[:upper:]", "\\p{Upper}",
      "[:LOWER:]", "\\p{Lower}",
      "[:lower:]", "\\p{Lower}",
      "[:DIGIT:]", "\\d",
      "[:digit:]", "\\d",
      "[:SPACE:]", " ",
      "[:space:]", " ",
      "[:WHITESPACE:]", "\\s",
      "[:whitespace:]", "\\s",
      "[:ALNUM:]", "\\p{Alnum}",
      "[:alnum:]", "\\p{Alnum}"
  };

  // It's important to have XDigit before Digit to match XDigit first
  // (i.e. see the posixRegexToPattern method)
  private static final String[] POSIX_CHARACTER_CLASSES = new String[] { "Lower", "Upper", "ASCII",
      "Alpha", "XDigit", "Digit", "Alnum", "Punct", "Graph", "Print", "Blank", "Cntrl", "Space" };

  private Like() {
  }

  /**
   * Translates a SQL LIKE pattern to Java regex pattern, with optional
   * escape string.
   */
  static String sqlToRegexLike(
      String sqlPattern,
      @Nullable CharSequence escapeStr) {
    final char escapeChar;
    if (escapeStr != null) {
      if (escapeStr.length() != 1) {
        throw invalidEscapeCharacter(escapeStr.toString());
      }
      escapeChar = escapeStr.charAt(0);
    } else {
      escapeChar = 0;
    }
    return sqlToRegexLike(sqlPattern, escapeChar);
  }

  /**
   * Translates a SQL LIKE pattern to Java regex pattern.
   */
  static String sqlToRegexLike(
      String sqlPattern,
      char escapeChar) {
    int i;
    final int len = sqlPattern.length();
    final StringBuilder javaPattern = new StringBuilder(len + len);
    for (i = 0; i < len; i++) {
      char c = sqlPattern.charAt(i);
      if (JAVA_REGEX_SPECIALS.indexOf(c) >= 0) {
        javaPattern.append('\\');
      }
      if (c == escapeChar) {
        if (i == (sqlPattern.length() - 1)) {
          throw invalidEscapeSequence(sqlPattern, i);
        }
        char nextChar = sqlPattern.charAt(i + 1);
        if ((nextChar == '_')
            || (nextChar == '%')
            || (nextChar == escapeChar)) {
          javaPattern.append(nextChar);
          i++;
        } else {
          throw invalidEscapeSequence(sqlPattern, i);
        }
      } else if (c == '_') {
        javaPattern.append('.');
      } else if (c == '%') {
        javaPattern.append("(?s:.*)");
      } else {
        javaPattern.append(c);
      }
    }
    return javaPattern.toString();
  }

  private static RuntimeException invalidEscapeCharacter(String s) {
    return new RuntimeException(
        "Invalid escape character '" + s + "'");
  }

  private static RuntimeException invalidEscapeSequence(String s, int i) {
    return new RuntimeException(
        "Invalid escape sequence '" + s + "', " + i);
  }

  private static void similarEscapeRuleChecking(
      String sqlPattern,
      char escapeChar) {
    if (escapeChar == 0) {
      return;
    }
    if (SQL_SIMILAR_SPECIALS.indexOf(escapeChar) >= 0) {
      // The escape character is a special character
      // SQL 2003 Part 2 Section 8.6 General Rule 3.b
      for (int i = 0; i < sqlPattern.length(); i++) {
        if (sqlPattern.charAt(i) == escapeChar) {
          if (i == (sqlPattern.length() - 1)) {
            throw invalidEscapeSequence(sqlPattern, i);
          }
          char c = sqlPattern.charAt(i + 1);
          if ((SQL_SIMILAR_SPECIALS.indexOf(c) < 0)
              && (c != escapeChar)) {
            throw invalidEscapeSequence(sqlPattern, i);
          }
        }
      }
    }

    // SQL 2003 Part 2 Section 8.6 General Rule 3.c
    if (escapeChar == ':') {
      int position;
      position = sqlPattern.indexOf("[:");
      if (position >= 0) {
        position = sqlPattern.indexOf(":]");
      }
      if (position < 0) {
        throw invalidEscapeSequence(sqlPattern, position);
      }
    }
  }

  private static RuntimeException invalidRegularExpression(
      String pattern, int i) {
    return new RuntimeException(
        "Invalid regular expression '" + pattern + "', index " + i);
  }

  private static int sqlSimilarRewriteCharEnumeration(
      String sqlPattern,
      StringBuilder javaPattern,
      int pos,
      char escapeChar) {
    int i;
    for (i = pos + 1; i < sqlPattern.length(); i++) {
      char c = sqlPattern.charAt(i);
      if (c == ']') {
        return i - 1;
      } else if (c == escapeChar) {
        i++;
        char nextChar = sqlPattern.charAt(i);
        if (SQL_SIMILAR_SPECIALS.indexOf(nextChar) >= 0) {
          if (JAVA_REGEX_SPECIALS.indexOf(nextChar) >= 0) {
            javaPattern.append('\\');
          }
          javaPattern.append(nextChar);
        } else if (escapeChar == nextChar) {
          javaPattern.append(nextChar);
        } else {
          throw invalidRegularExpression(sqlPattern, i);
        }
      } else if (c == '-') {
        javaPattern.append('-');
      } else if (c == '^') {
        javaPattern.append('^');
      } else if (sqlPattern.startsWith("[:", i)) {
        int numOfRegCharSets = REG_CHAR_CLASSES.length / 2;
        boolean found = false;
        for (int j = 0; j < numOfRegCharSets; j++) {
          if (sqlPattern.startsWith(REG_CHAR_CLASSES[j + j], i)) {
            javaPattern.append(REG_CHAR_CLASSES[j + j + 1]);

            i += REG_CHAR_CLASSES[j + j].length() - 1;
            found = true;
            break;
          }
        }
        if (!found) {
          throw invalidRegularExpression(sqlPattern, i);
        }
      } else if (SQL_SIMILAR_SPECIALS.indexOf(c) >= 0) {
        throw invalidRegularExpression(sqlPattern, i);
      } else {
        javaPattern.append(c);
      }
    }
    return i - 1;
  }

  /**
   * Translates a SQL SIMILAR pattern to Java regex pattern, with optional
   * escape string.
   */
  static String sqlToRegexSimilar(
      String sqlPattern,
      @Nullable CharSequence escapeStr) {
    final char escapeChar;
    if (escapeStr != null) {
      if (escapeStr.length() != 1) {
        throw invalidEscapeCharacter(escapeStr.toString());
      }
      escapeChar = escapeStr.charAt(0);
    } else {
      escapeChar = 0;
    }
    return sqlToRegexSimilar(sqlPattern, escapeChar);
  }

  /**
   * Translates SQL SIMILAR pattern to Java regex pattern.
   */
  static String sqlToRegexSimilar(
      String sqlPattern,
      char escapeChar) {
    similarEscapeRuleChecking(sqlPattern, escapeChar);

    boolean insideCharacterEnumeration = false;
    final StringBuilder javaPattern =
        new StringBuilder(sqlPattern.length() * 2);
    final int len = sqlPattern.length();
    for (int i = 0; i < len; i++) {
      char c = sqlPattern.charAt(i);
      if (c == escapeChar) {
        if (i == (len - 1)) {
          // It should never reach here after the escape rule
          // checking.
          throw invalidEscapeSequence(sqlPattern, i);
        }
        char nextChar = sqlPattern.charAt(i + 1);
        if (SQL_SIMILAR_SPECIALS.indexOf(nextChar) >= 0) {
          // special character, use \ to replace the escape char.
          if (JAVA_REGEX_SPECIALS.indexOf(nextChar) >= 0) {
            javaPattern.append('\\');
          }
          javaPattern.append(nextChar);
        } else if (nextChar == escapeChar) {
          javaPattern.append(nextChar);
        } else {
          // It should never reach here after the escape rule
          // checking.
          throw invalidEscapeSequence(sqlPattern, i);
        }
        i++; // we already process the next char.
      } else {
        switch (c) {
        case '_':
          javaPattern.append('.');
          break;
        case '%':
          javaPattern.append("(?s:.*)");
          break;
        case '[':
          javaPattern.append('[');
          insideCharacterEnumeration = true;
          i =
              sqlSimilarRewriteCharEnumeration(sqlPattern, javaPattern,
                  i, escapeChar);
          break;
        case ']':
          if (!insideCharacterEnumeration) {
            throw invalidRegularExpression(sqlPattern, i);
          }
          insideCharacterEnumeration = false;
          javaPattern.append(']');
          break;
        case '\\':
          javaPattern.append("\\\\");
          break;
        case '$':

          // $ is special character in java regex, but regular in
          // SQL regex.
          javaPattern.append("\\$");
          break;
        default:
          javaPattern.append(c);
        }
      }
    }
    if (insideCharacterEnumeration) {
      throw invalidRegularExpression(sqlPattern, len);
    }

    return javaPattern.toString();
  }

  static java.util.regex.Pattern posixRegexToPattern(String regex,
      boolean caseSensitive) {
    int flags = caseSensitive ? 0 : java.util.regex.Pattern.CASE_INSENSITIVE;
    return posixRegexToPattern(regex, flags);
  }

  static java.util.regex.Pattern posixRegexToPattern(String regex, int flags) {
    // Replace existing character classes with java equivalent ones
    String originalRegex = regex;
    String[] existingExpressions = Arrays.stream(POSIX_CHARACTER_CLASSES)
        .filter(v -> originalRegex.contains(v.toLowerCase(Locale.ROOT)))
        .toArray(String[]::new);
    for (String v : existingExpressions) {
      regex = regex.replace(v.toLowerCase(Locale.ROOT), "\\p{" + v + "}");
    }

    return java.util.regex.Pattern.compile(regex, flags);
  }
}
